goog.provide('dkpoints.nav');
goog.provide('dkpoints.nav.Nav');
goog.provide('dkpoints.nav.Path');

goog.require('dkpoints.auth.AuthState');
goog.require('dkpoints.errors.templates');
goog.require('dkpoints.nav.Handler');
goog.require('dkpoints.urls');
goog.require('dkpoints.utils');

goog.require('goog.History');
goog.require('goog.dom');
goog.require('goog.events');
goog.require('goog.history.Event');
goog.require('goog.history.EventType');
goog.require('goog.structs.Map');
goog.require('goog.userAgent');


/**
 * @fileoverview
 *
 * dkpoints.nav.Nav is responsible for routing navigation events to the
 * appropriate handlers and coordinating transitions between them.
 *
 * When a navigation event is triggered:
 * route(event) {
 *    if (this.backtracking_ && event.token === this.page_) {
 *      // The user cancelled navigation, so this was the event caused by
 *      // rolling back the URL hash.
 *      this.backtracking_ = false;
 *      this.page_ = this.referer_;
 *      this.referer_ = this.backtrackingReferer_;
 *      this.backtrackingReferer_ = '';
 *
 *      return;
 *    }
 *
 *    path = disectPath(event.token) - Parse path/params and get handler.
 *    // If no handler was found, or the handler that was found does not require
 *    // authentication, don't bother with the getAuthStatus call.
 *
 *    if (path.handler && path.handler.requiredAuth > NO_AUTH) {
 *      auth.getAuthStatus(verifyAuth(path));
 *    }
 *    else {
 *      if (path.handler === undefined) {
 *        path.handler = dkpoints.urls[':404']; // HTTP 404 Not Found
 *      }
 *      else if (path.handler === null) {
 *        path.handler = dkpoints.urls[':501']; // HTTP 501 Not Implemented.
 *      }
 *
 *      verifyAuth(path, null);
 *    }
 * }
 *
 * verifyAuth(path, authStatus) {
 *    authState = authStatus === null ? NO_AUTH : authStatus.getState();
 *
 *    if (path.handler.requiredAuth > authState) {
 *       path.handler = dkpoints.urls[':403']; // HTTP 403 Forbidden
 *    }
 *
 *    if (activeHandler) {
 *      if (activeHandler instanceof path.handler) {
 *        activateHandler(path, isTransition=true);
 *      }
 *      else {
 *        activeHandler.dispose(this.postDisposeHook_(path));
 *        // postDisposeHook will call activateHandler(path, false) if dispose
 *        // completes or rollback() if the user cancels navigation.
 *      }
 *    }
 *    else {
 *      activateHandler(path, isTransition=false)
 *    }
 * }
 *
 * postDisposeHook(path, completed) {
 *    if (completed) {
 *      activateHandler(path, isTransition=false);
 *    }
 *    else {
 *      rollback();
 *    }
 * }
 *
 * activateHandler(path, isTransition) {
 *    var handler;
 *    if (! path.handler.instance) {
 *      path.handler.instance = handler = new path.handler(serviceProvider);
 *    }
 *
 *    if (! isTransition) {
 *      this.activeHandler_ = handler;
 *      handler.handle(path.params);
 *    }
 *    else {
 *      this.backtrackReferer_ = this.referer_;
 *      this.referer_ = this.page_;
 *      this.page_ = path.hash;
 *      handler.transition(path, this.rollback);
 *    }
 * }
 *
 * rollback() {
 *   this.backtracking_ = true;
 *   history.back();
 * }
 */


/**
 * A record type that contains the components of a URL hash token broken into:
 * - hash: the full event.token string
 * - path: the path that matches an entry in dkpoints.urls
 * - params: everything after path in hash to be passed to the handler as params
 * - handler: the dkpoints.nav.Handler constructor registered for this path
 *    (or undefined if the path could not be matched to a mapped URL).
 *
 * @typedef {{hash: string, path: string, params: string,
 *            handler: (function(new: dkpoints.nav.Handler,
 *                               dkpoints.ServiceProvider)|null)}}
 */
dkpoints.nav.Path;



/**
 * The Nav class is responsible for routing location changes to the appropriate
 * subsystem.
 * @param {dkpoints.ServiceProvider} serviceProvider The service provider for
 *    this app's subsystems.
 * @constructor
 */
dkpoints.nav.Nav = function(serviceProvider) {
  serviceProvider.setNavManager(this);

  var url = dkpoints.utils.getLocationHref(),
      hashIndex = url.lastIndexOf('#'),
      containsHash = (hashIndex === -1) ? false : true;

  // URL may end in '', '/', '#', '/#', or '/#/some/path'.
  // this.baseURL_ needs to handle all of these.
  // baseURL should NOT end in a trailing slash.
  var base = '';
  if (! containsHash) {
    base = url.charAt(url.length - 1) === '/' ? url.match(/(.*)\/$/)[1] : url;
  }
  else {
    base = url.charAt(hashIndex - 1) === '/' ?
        url.match(/(.*)\/#/)[1] : url.match(/(.*)#/)[1];
  }

  /**
   * @type {string}
   * @private
   */
  this.baseURL_ = base;
  serviceProvider.setBaseURL(base);

  var defaultPath = this.disectPath('');
  /**
   * The current page.  Gets set in handleNavigation_.
   *
   * @type {dkpoints.nav.Path}
   * @private
   */
  this.page_ = defaultPath;

  /**
   * The previous page.  Gets set in handleNavigation_.
   *
   * @type {dkpoints.nav.Path}
   * @private
   */
  this.referer_ = defaultPath;

  /**
   * Keep track of the page before referer in case we need to roll back
   * the navigation state on a cancelled dispose or transition.
   *
   * @type {dkpoints.nav.Path}
   * @private
   */
  this.backtrackReferer_ = defaultPath;

  /**
   * Set to true only when the user declines to leave the current page.
   * @type {boolean}
   * @private
   */
  this.backtracking_ = false;

  /**
   * In Webkit browsers, the Navigate event is fired twice if the page is
   * initially loaded without a # in the URL (once on page load, again when
   * goog.History adds the '#' to the URL).
   *
   * @type {boolean}
   * @private
   */
  this.skipFirst_ = goog.userAgent.WEBKIT && (! containsHash);

  /**
   * @type {dkpoints.ServiceProvider}
   * @private
   */
  this.serviceProvider_ = serviceProvider;

  var auth;
  /**
   * @type {dkpoints.auth.Auth}
   * @private
   */
  this.auth_ = auth = serviceProvider.getAuth();
  auth.setNavManager(this);

  /**
   * @type {dkpoints.rpc.commands.AuthCheckResponse}
   * @private
   */
  this.authStatus_ = null;

  /**
   * @type {dkpoints.nav.Handler}
   * @private
   */
  this.activeHandler_ = null;

  var iframe = /** @type {HTMLIFrameElement} */ (goog.dom.$('historyFrame'));
  var input = /** @type {HTMLInputElement} */ (goog.dom.$('historyInput'));

  var navCallback = goog.bind(this.route_, this);
  var h = new goog.History(false, '/scripts/blank.html', input, iframe);
  goog.events.listen(h, goog.history.EventType.NAVIGATE, navCallback);
  h.setEnabled(true);

  /**
   * @type {goog.History}
   * @private
   */
  this.history_ = h;
};


/**
 * Determines the appropriate handler based on the path, either creates a new
 * one or gets an existing one, figures out dependencies and params, then
 * activates.
 *
 * @private
 * @param {goog.history.Event} event The navigation event.
 */
dkpoints.nav.Nav.prototype.route_ = function(event) {
  // See constructor for why this hack is necessary.
  if (this.skipFirst_ === true && event.isNavigation && event.token === '') {
    this.skipFirst_ = false;
    return;
  }

  // If the user cancels a navigation, we need to roll back the URL but not
  // handle the navigation event (because they're already looking at whatever
  // it is they want to be looking at).
  if (this.backtracking_) {
    this.page_ = this.referer_;
    this.referer_ = this.backtrackingReferer_;
    this.backtrackingReferer_ = null;

    this.backtracking_ = false;
    return;
  }

  var token = event.token,
      path = this.disectPath(token);

  this.backtrackingReferer_ = this.referer_;
  this.referer_ = this.page_;
  this.page_ = path;

  if (path.handler === undefined) {
    // An undefined handler means that this token was not declared in the
    // URL map, so should show a 404 (Not Found) error.
    path.handler = dkpoints.urls[':404'];
  }
  else if (path.handler === null) {
    // A token mapped to a null handler indicates that the developer intends
    // to implement this path's handler, but has not yet done so.  Show a
    // 501 (Not Implemented) error.
    path.handler = dkpoints.urls[':501'];
  }

  if (! this.authStatus_) {
    // If we have not received a setAuthStatus method call, explicitly go get
    // the status.
    var authCallback = goog.bind(this.verifyAuth_, this, path);
    this.auth_.getAuthStatus(authCallback);
  }
  else {
    // If we do, just give it to verifyAuth directly.
    this.verifyAuth_(path, this.authStatus_);
  }
};


/**
 * Once this.route_ gets the handler for the path, execution flows to
 * verifyAuth to ensure the user has the proper authorizations to execute
 * the attached handler.  Handlers assume that the user is allowed to see them.
 * @param {dkpoints.nav.Path} path The path object for this navigation.
 * @param {!dkpoints.rpc.commands.AuthCheckResponse} authStatus The user's
 *    authentication status.
 * @private
 */
dkpoints.nav.Nav.prototype.verifyAuth_ = function(path, authStatus) {
  path.authStatus = authStatus;
  var authState = authStatus.getState();

  if (path.handler.requiredAuth > authState) {
    // Store a reference to NotAuthorizedHandler knows what message to show.
    path.requiredAuth = path.handler.requiredAuth;
    path.handler = dkpoints.urls[':403'];
  }

  if (this.activeHandler_) {
    if (this.activeHandler_ instanceof path.handler) {
      this.activateHandler_(path, true);
    }
    else {
      this.activeHandler_.exit(goog.bind(this.onExit_, this, path));
    }
  }
  else {
    this.activateHandler_(path, false);
  }
};


/**
 * Callback to handler.dispose
 * @param {dkpoints.nav.Path} path The path object for this navigation flow.
 * @param {boolean} disposeComplete True if the handler was disposed.  False
 *    otherwise.
 * @private
 */
dkpoints.nav.Nav.prototype.onExit_ = function(path, disposeComplete) {
  if (disposeComplete) {
    this.activateHandler_(path, false);
  }
  else {
    this.rollback_();
  }
};


/**
 * Either instantiate or get an existing instance of path.handler (constructor).
 *
 * @param {dkpoints.nav.Path} path The path object describing the current
 *    request.
 * @param {boolean} isTransition Whether to call handler.transition or
 *    handler.handle.
 * @private
 */
dkpoints.nav.Nav.prototype.activateHandler_ = function(path, isTransition) {
  // If this location does not map to a handler, then path.handler has already
  // been reassigned the appropriate Not Found or Not Yet Implemented handler
  // constructor.
  var handlerCtor =
      /** @type {function(new:dkpoints.nav.Handler,
        * dkpoints.ServiceProvider)} */ (path.handler);
  var handler = null;

  if (handlerCtor.instance) {
    handler = handlerCtor.instance;
  }
  else {
    /**
     * @type {!dkpoints.nav.Handler}
     * @private
     */
    this.activeHandler_ = handler = new handlerCtor(this.serviceProvider_);
    handlerCtor.instance = handler;
  }

  if (! isTransition) {
    handler.handle(path);
  }
  else {
    handler.transition(path, goog.bind(this.rollback_, this));
  }
};


/**
 * If the user decides during either the dispose or transition calls not to
 * leave their current page, this method will be called to roll back the
 * navigation state.
 * @private
 */
dkpoints.nav.Nav.prototype.rollback_ = function() {
  this.backtracking_ = true;

  // Using this.history_.replaceHash results in two identical tokens in the
  // history stack.  This method leaves the unintended page as the only token
  // in the history.forward stack, but it behaves as expected otherwise.
  goog.global.history.back();
};


/**
 * Helper method to determine the handler, token, and path params of a
 * hash token.
 * @param {string} hash The event.token from a navigation event.
 * @return {dkpoints.nav.Path} Object containing the token, params, and handler
 *    for the given url hash location.  If the given hash could not be mapped
 *    to a handler, the resulting Path object's path and params properties
 *    will be empty strings.
 */
dkpoints.nav.Nav.prototype.disectPath = function(hash) {
  var path = hash, // token will be modified.  Keep fullHash untouched.
      params = '',
      pathIndex = path.length - 1;


  // Remember:  Null handlers are valid matches, so we must distinguish between
  // null (not yet implemented) and undefined (not found).
  var handler = dkpoints.urls[hash];
  while (handler === undefined && pathIndex > 0) {
    // Check if the whole of path is a path handled in dkpoints.urls
    handler = dkpoints.urls[path];
    if (handler === undefined) {
      // If no handler was found at the full path, start chopping off pieces
      // at each '/'.
      pathIndex = path.lastIndexOf('/');
      if (pathIndex > -1) {
        // Anything that's not part of the path is a parameter to be passed
        // to the handler.
        params = params === '' ?
            path.substring(pathIndex + 1) :
            [path.substring(pathIndex + 1), params].join('/');
        path = path.substring(0, pathIndex);
      }
    }
  }

  // handler === null is a valid handler (indicated not-yet-implemented).
  // Don't default to empty path and param strings for this.
  return {
    hash: hash,
    path: handler !== undefined ? path : '',
    params: handler !== undefined ? params : '',
    handler: /** @type {function(this: dkpoints.nav.Handler,
        dkpoints.ServiceProvider)|null} */ (handler)
  };
};


/**
 * @return {string} Gets the previous path object.
 */
dkpoints.nav.Nav.prototype.getReferer = function() {
  return this.referer_.hash;
};


/**
 * @param {dkpoints.rpc.commands.AuthCheckResponse} authStatus
 *    The user's current authStatus.  This should only ever be set by the
 *    auth manager.
 */
dkpoints.nav.Nav.prototype.setAuthStatus = function(authStatus) {
  this.authStatus_ = authStatus || null;
};
